// Copyright 2009, Google Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

// Author: api.anash@gmail.com (Anash P. Oommen)

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Net;
using System.Net.Cache;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Web;
using System.Xml;

namespace ExtremeSwank.OpenId.PlugIns.Discovery {

  /// <summary>
  /// GoogleApps Discovery Plugin.
  /// Provides everything needed to perform GoogleApps xrds discovery.
  /// Depends on <see cref="Xrds" /> to decode resulting XRDS document.
  /// </summary>
  [Serializable]
  public class GoogleAppsDiscovery : IDiscovery {
    /// <summary>
    /// Creates a new instance of GoogleAppsDiscovery.
    /// </summary>
    /// <param name="state">Parent <see cref="StateContainer" /> object.</param>
    public GoogleAppsDiscovery(StateContainer state) {
      Parent = state;
      state.RegisterPlugIn(this);
    }

    /// <summary>
    /// Create a new instance of GoogleAppsDiscovery.
    /// </summary>
    /// <param name="client">Parent <see cref="ClientCore" /> object.</param>
    public GoogleAppsDiscovery(ClientCore client) {
      Parent = client.StateContainer;
      Parent.RegisterPlugIn(this);

      // NOTE(Anash): Register ourselves at the head of the plugin chain. We need
      // a shot at discovery before the HTML plugin does, otherwise it will
      // wrongly conclude that this is a valid HTML based discovery link.
      for(int i = 0; i < Parent.DiscoveryPlugIns.Count; i++) {
        if (Parent.DiscoveryPlugIns[i] == this) {
          IDiscovery temp = Parent.DiscoveryPlugIns[0];
          Parent.DiscoveryPlugIns[0] = this;
          Parent.DiscoveryPlugIns[i] = temp;
        }
      }
    }

    /// <summary>
    /// Get the human-readable name of this plug-in.
    /// </summary>
    /// <returns>A string containing the plug-in name.</returns>
    public override string ToString() {
      return Name;
    }

    /// <summary>
    /// Gets the human-readable name of the discovery plugin.
    /// </summary>
    /// <remarks>Always returns "GoogleAppsDiscovery Discovery Plugin".</remarks>
    public string Name {
      get {
        return _Name;
      }
    }

    /// <summary>
    /// Parent OpenID object.
    /// </summary>
    public StateContainer Parent {
      get {
        return _Parent;
      }
      set {
        _Parent = value;
      }
    }

        /// <summary>
    /// Highest version of OpenID protocol supported by the discovered
    /// Identity Provider.
    /// </summary>
    public ProtocolVersion Version {
      get {
        return _PV;
      }
    }

    /// <summary>
    /// Accepts a claimed identifier and returns
    /// the normalized identifier, and an end-point URL.
    /// </summary>
    /// <param name="openid">Claimed identifier.</param>
    /// <returns>Null</returns>
    public NormalizationEntry ProcessId(string openid) {
      NormalizationEntry retVal = null;
      Uri url = new Uri(openid);

      // Try to retrieve the site xrds url from host-meta.
      string siteXrdsUrl = DiscoverSiteXrdsFromHostMeta(url.DnsSafeHost.Replace("www.", ""));
      if (string.IsNullOrEmpty(siteXrdsUrl)) {
        return retVal;
      }

      // see if we can retrieve that xrds, and validate it.
      string siteXrdsContents = "";
      try {
        siteXrdsContents = FetchSiteXrds(siteXrdsUrl);
      } catch (ApplicationException ex) {
        Trace.Write(ex.Message, "INFO");
        return retVal;
      }

      // site xrds canonical id should be the same as the openid domain.
      string canonicalID = FetchCanonicalIDFromXrds(siteXrdsContents);
      if (string.Compare(url.DnsSafeHost, canonicalID, true) != 0) {
        return retVal;
      }

      normalizedId = "";
      authority = "";
      this.openId = openid;

      // Get the normalized link for the user.
      GetNormalizedIdFromSiteXrds(siteXrdsContents, openid, ref normalizedId, ref authority);
      if (string.IsNullOrEmpty(normalizedId)) {
        return retVal;
      }

      // And return it.
      retVal = new NormalizationEntry();
      retVal.DiscoveryUrl = new Uri(normalizedId);
      retVal.FriendlyId = normalizedId;
      retVal.NormalizedId = normalizedId;
      return retVal;
    }

    /// <summary>
    /// Fetches the site xrds.
    /// </summary>
    /// <param name="url">The url from which site xrds should be
    /// downloaded.</param>
    /// <returns>The site xrds contents as a string.</returns>
    /// <remarks>This method will also perform the signature
    /// validation.</remarks>
    public string FetchSiteXrds(string url) {
      string contents = "";
      WebResponse response = null;

      try {
        contents = FetchWebPage(url, out response);
      } catch (ApplicationException ex) {
        throw new ApplicationException(NO_SITE_XRDS_ERROR, ex);
      }

      if (VerifySignature(contents, response.Headers[SIGNATURE_HEADERNAME]) == false) {
        throw new ApplicationException(SITE_XRDS_SIGNATURE_INVALID_ERROR);
      }

      return contents;
    }

    /// <summary>
    /// Gets the normalized identifier for a claimed id.
    /// </summary>
    /// <param name="content">The site xrds contents, as string.</param>
    /// <param name="openid">The claimed id.</param>
    /// <param name="normalizedId">The normalized id.</param>
    /// <param name="authority">The site authority.</param>
    private void GetNormalizedIdFromSiteXrds(string content, string openid,
        ref string normalizedId, ref string authority) {
      XmlDocument doc = new XmlDocument();
      doc.LoadXml(content);
      XmlNamespaceManager xmlns = GetNsMgr(doc);

      // Retrieve the uri template, and substitute the claimed id.
      XmlElement xNode = (XmlElement) doc.SelectSingleNode(
          string.Format(NORMALIZED_ID_XPATH, OPENID_TYPE, URI_TEMPLATE_NODENAME), xmlns);

      if (xNode != null) {
        normalizedId = xNode.InnerText.Replace("{%uri}", HttpUtility.UrlEncode(openid));
      } else {
        normalizedId = "";
      }
      xNode = (XmlElement) doc.SelectSingleNode(
          string.Format(AUTHORITY_XPATH, OPENID_TYPE, NEXT_AUTHORITY_NODENAME), xmlns);
      if (xNode != null) {
        authority = xNode.InnerText;
      } else {
        authority = "";
      }

    }

    /// <summary>
    /// Verify the signature for xrds document.
    /// </summary>
    /// <param name="contents">The xrds document contents.</param>
    /// <param name="signature">The xrds document signature.</param>
    /// <returns>True, if the signature is valid, and the certificates
    /// that signed the xrds are valid.</returns>
    private bool VerifySignature(string contents, string signature) {
      XmlDocument doc = new XmlDocument();
      doc.LoadXml(contents);
      XmlNamespaceManager xmlns = GetNsMgr(doc);

      XmlNodeList certificates = doc.SelectNodes(DIGITAL_SIGNATURE_XPATH, xmlns);

      if (certificates != null && certificates.Count != 0) {
        byte[] certificateBytes = Convert.FromBase64String(certificates[0].Value);
        X509Certificate2 certificate = new X509Certificate2(certificateBytes);

        if (certificate != null && certificate.PublicKey != null) {
          bool is_valid = VerifyXrdsSignature(contents, signature, certificate);

          if (is_valid) {
            string[] splits = certificate.Subject.Split(',');
            foreach (string split in splits) {
              if (split.StartsWith("CN=")) {
                issuedTo = split.Trim().Replace("CN=", "");
                break;
              }
            }
            if (ValidateCertificateChain(certificates, certificate)) {
              return true;
            }
          }
        }
      }
      return false;
    }

    /// <summary>
    /// Verify if the xrds signature is valid.
    /// </summary>
    /// <param name="contents">XRDS document contents.</param>
    /// <param name="signature">XRDS signature.</param>
    /// <param name="certificate">The certificate used to sign the document.</param>
    /// <returns>True, if the xrds has been signed properly.</returns>
    private bool VerifyXrdsSignature(string contents, string signature,
        X509Certificate2 certificate) {
      RSACryptoServiceProvider csp = (RSACryptoServiceProvider) certificate.PublicKey.Key;

      // TODO: Will be nice to return false if the algorithm
      // mentioned in xrds is not rsa-sha1. Right now, signature
      // verification will fail, and discovery will not proceed,
      // thus achieving the same effect.

      SHA1Managed sha1 = new SHA1Managed();
      byte[] data = Encoding.UTF8.GetBytes(contents);
      byte[] hash = sha1.ComputeHash(data);
      string test = Convert.ToBase64String(hash);
      bool is_valid = csp.VerifyHash(hash, CryptoConfig.MapNameToOID("SHA1"),
          Convert.FromBase64String(signature));
      return is_valid;
    }

    /// <summary>
    /// Validate the certificate chain in xrds document.
    /// </summary>
    /// <param name="certificateNodes">The certificate nodes from xrds document.</param>
    /// <param name="certificate">The certificate used to sign xrds.</param>
    /// <returns>True, if the certificate is valid, and the chain is rooted
    /// in a trusted domain.</returns>
    private bool ValidateCertificateChain(XmlNodeList certificateNodes,
        X509Certificate2 certificate) {
      X509Chain chain = new X509Chain();
      byte[] certificateBytesTemp = null;
      foreach (XmlNode certificateNode in certificateNodes) {
        certificateBytesTemp = Convert.FromBase64String(certificateNode.Value);
        X509Certificate2 certificateTemp = new X509Certificate2(certificateBytesTemp);
        chain.ChainPolicy.ExtraStore.Add(certificateTemp);
      }
      bool canBuildChain = chain.Build(certificate);
      if (canBuildChain || issuedTo == GOOGLE_HOSTED_DOMAIN) {
        return true;
      }
      return false;
    }

    /// <summary>
    /// Perform GoogleAppsDiscovery discovery on a HTTP response, automatically
    /// retrieving and processing
    /// discovered documents.
    /// </summary>
    /// <param name="content">HTTP response content to parse.</param>
    /// <returns>An array of DiscoveryResult objects.</returns>
    public DiscoveryResult[] Discover(string content) {
      int xmlLocation = content.IndexOf("<?xml version");
      if (xmlLocation == -1) {
        return null;
      }

      string header = content.Substring(0, xmlLocation);
      string body = content.Substring(xmlLocation);
      string signature = "";
      string[] splits = header.Split(new char[] {'\r', '\n'},
          StringSplitOptions.RemoveEmptyEntries);
      foreach(string split in splits) {
        if(split.StartsWith(SIGNATURE_HEADERNAME)) {
          signature = split.Replace(SIGNATURE_HEADERNAME + ":", "");
          break;
        }
      }

      // The signature should be valid, the chain should trace back to a trusted root.
      if (VerifySignature(body, signature) == false) {
        return null;
      }

      // If the authority is mentioned, then it should match to the issuing party.
      if (!string.IsNullOrEmpty(authority)) {
        if (string.Compare(authority, issuedTo, true) != 0) {
          return null;
        }
      }

      // The content id should match with the claimed id.
      if (string.Compare(FetchCanonicalIDFromXrds(body), this.openId, true) != 0) {
        return null;
      }

      Xrds xrds = GetXrdsPlugin();
      DiscoveryResult[] retVal = xrds.Discover(content);
      return retVal;
    }

    /// <summary>
    /// Get the provider url from site xrds.
    /// </summary>
    /// <param name="xrdsUrl">The xrds url.</param>
    /// <returns></returns>
    public string DiscoverProviderUrlFromXrds(string xrdsUrl) {
      string retVal = "";

      if (string.IsNullOrEmpty(xrdsUrl)) {
        throw new ArgumentException(XRDS_URL_CANNOT_BE_EMPTY);
      }

      try {
        string contents = FetchSiteXrds(xrdsUrl);
        Xrds xrds = GetXrdsPlugin();
        DiscoveryResult[] results = xrds.Discover(contents);
        if (results.Length > 0) {
          retVal = results[0].ServerUrl.AbsoluteUri;
        }
      } catch (Exception ex) {
        throw new ApplicationException(PROVIDER_URL_NOT_FOUND_ERROR, ex);
      }

      return retVal;

    }

    /// <summary>
    /// Discover the site xrds using host-meta approach, given a domain.
    /// </summary>
    /// <param name="domainUrl">The domain for which discovery should be
    /// done.</param>
    /// <returns>The site xrds url as a string, or empty string if discovery
    /// fails.</returns>
    public string DiscoverSiteXrdsFromHostMeta(string domainUrl) {
      string[] metaEndPoints = new string[] {
        string.Format(GOOGLE_HOSTED_APPS_HOST_META_ENDPOINT, domainUrl),
        string.Format(DEFAULT_HOST_META_ENDPOINT, domainUrl),
      };

      string retVal = "";
      WebResponse response = null;
      foreach (string metaEndpoint in metaEndPoints) {
        try {
          string contents = FetchWebPage(metaEndpoint, out response);
          string[] splits = contents.Split(new char[] { ';' },
              StringSplitOptions.RemoveEmptyEntries);

          foreach (string split in splits) {
            if (split.StartsWith("Link:")) {
              retVal = split.Substring(5).Trim(HOST_META_TRIMCHARS);
              if (!string.IsNullOrEmpty(retVal)) {
                return retVal;
              }
            }
          }
        } catch (ApplicationException ex) {
          // This is sort of a safe catch, since we expect that some of the
          // endpoints may indeed fail.
          Trace.Write(ex.Message, "WARN");
        }
      }
      if (retVal == "") {
        throw new ApplicationException(HOST_META_DISCOVERY_FAILED);
      }
      return retVal;
    }

    /// <summary>
    /// Fetch the openid provider url, given the email.
    /// </summary>
    /// <param name="email">The email address.</param>
    /// <returns>The openid provider url.</returns>
    public string FetchProviderUrlFromEmail(string email) {
      string providerUrl = "";
      try {
        string domain = FetchDomainFromEmail(email);
        string xrdsEndPoint = DiscoverSiteXrdsFromHostMeta(domain);
        providerUrl = DiscoverProviderUrlFromXrds(xrdsEndPoint);
      } catch (ApplicationException ex) {
        throw new ApplicationException(PROVIDER_URL_NOT_FOUND_ERROR, ex);
      } catch (ArgumentException ex) {
        throw new ApplicationException(PROVIDER_URL_NOT_FOUND_ERROR, ex);
      }
      return providerUrl;
    }

    /// <summary>
    /// Fetches the domain name, given an email.
    /// </summary>
    /// <param name="email">The email address.</param>
    /// <returns>The domain for the email address.</returns>
    public string FetchDomainFromEmail(string email) {
      if (string.IsNullOrEmpty(email)) {
        throw new ArgumentException(EMAIL_CANNOT_BE_EMPTY);
      }

      string[] splits = email.Split('@');
      if (splits.Length == 2) {
        return splits[1];
      } else {
        throw new ArgumentException(INVALID_EMAIL_FORMAT);
      }
    }

    /// <summary>
    /// Gets the canonical id from xrds document.
    /// </summary>
    /// <param name="contents"></param>
    /// <returns></returns>
    private string FetchCanonicalIDFromXrds(string contents) {
      XmlDocument doc = new XmlDocument();
      doc.LoadXml(contents);
      XmlNode node = doc.SelectSingleNode(CANONICAL_ID_XPATH, GetNsMgr(doc));
      if (node != null) {
        return node.InnerText;
      }
      return "";
    }

    /// <summary>
    /// Constructs a namespace manager for parsing openid xrds.
    /// </summary>
    /// <param name="doc">The xml document for xrds.</param>
    /// <returns>The namespace manager for xrds.</returns>
    private XmlNamespaceManager GetNsMgr(XmlDocument doc) {
      XmlNamespaceManager xmlns = new XmlNamespaceManager(doc.NameTable);
      xmlns.AddNamespace("xri", XRI_NS);
      xmlns.AddNamespace("ds", DIGITAL_SIGNATURE_NS);
      xmlns.AddNamespace("xrds", XRDS_NS);
      xmlns.AddNamespace("openid", OPENID_NS);
      return xmlns;
    }

    /// <summary>
    /// Fetch a web page, given a url.
    /// </summary>
    /// <param name="url">The url to be fetched.</param>
    /// <param name="response">The web respnse.</param>
    /// <returns>The web page contents, or empty string if failed.</returns>
    private string FetchWebPage(string url, out WebResponse response) {
      string contents = "";
      response = null;
      try {
        WebRequest request = HttpWebRequest.Create(url);
        // Use default IE cache settings.
        request.CachePolicy = new RequestCachePolicy(RequestCacheLevel.Default);
        response = request.GetResponse();
        using (StreamReader rdr = new StreamReader(response.GetResponseStream())) {
          contents = rdr.ReadToEnd();
        }
      } catch (Exception ex) {
        throw new ApplicationException(NO_WEB_PAGE_ERROR, ex);
      }
      return contents;
    }

    /// <summary>
    /// Gets the xrds plugin from discovery chain to assist discovery.
    /// </summary>
    /// <returns>The xrds discovery plugin instance.</returns>
    private Xrds GetXrdsPlugin() {
      foreach(IDiscovery discoveryPlugin in Parent.DiscoveryPlugIns) {
        if (discoveryPlugin is Xrds) {
          return (discoveryPlugin as Xrds);
        }
      }
      return null;
    }

    /// <summary>
    /// The parent container that holds this plugin.
    /// </summary>
    private StateContainer _Parent;

    /// <summary>
    /// The supported protocol version. Is always null.
    /// </summary>
    ProtocolVersion _PV;

    /// <summary>
    /// Default set of chars to trim for while getting xrds link from host-meta.
    /// </summary>
    readonly char[] HOST_META_TRIMCHARS = { ' ', '<', '>' };

    /// <summary>
    /// The site to which the signing certificate was issued.
    /// </summary>
    string issuedTo = "";

    /// <summary>
    /// The normalized id after discovery.
    /// </summary>
    string normalizedId = "";

    /// <summary>
    /// The authority which provided this service.
    /// </summary>
    string authority = "";

    /// <summary>
    /// The claimed openid being verified.
    /// </summary>
    string openId = "";

    /// <summary>
    /// Friendly identifier name for this plugin.
    /// </summary>
    const string _Name = "GoogleApps Discovery Plugin";

    /// <summary>
    /// Node name for xrds url template.
    /// </summary>
    const string URI_TEMPLATE_NODENAME = "URITemplate";

    /// <summary>
    /// Node name for the authority.
    /// </summary>
    const string NEXT_AUTHORITY_NODENAME = "NextAuthority";

    /// <summary>
    /// The header name that holds xrds signature.
    /// </summary>
    const string SIGNATURE_HEADERNAME = "Signature";

    #region url endpoints

    /// <summary>
    /// Alternate host-meta endpoint for google hosted apps.
    /// </summary>
    const string GOOGLE_HOSTED_APPS_HOST_META_ENDPOINT =
        "https://www.google.com/accounts/o8/.well-known/host-meta?hd={0}";

    /// <summary>
    /// Default host-meta endpoint as per the discovery protocol.
    /// </summary>
    const string DEFAULT_HOST_META_ENDPOINT = "http://{0}/.well-known/host-meta";

    /// <summary>
    /// The hosted domain alias for sites hosted on google.com.
    /// </summary>
    const string GOOGLE_HOSTED_DOMAIN = "hosted-id.google.com";

    #endregion

    #region xml namespaces

    /// <summary>
    /// Xml namespace for xri in xrds.
    /// </summary>
    const string XRI_NS = "xri://$xrd*($v*2.0)";

    /// <summary>
    /// Xml namespace for ds in xrds.
    /// </summary>
    const string DIGITAL_SIGNATURE_NS = "http://www.w3.org/2000/09/xmldsig#";

    /// <summary>
    /// Xml namespace for openid in xrds.
    /// </summary>
    const string OPENID_NS = "http://namespace.google.com/openid/xmlns";

    /// <summary>
    /// Xml namespace for xrds in xrds.
    /// </summary>
    const string XRDS_NS = "xri://$xrds";

    /// <summary>
    /// Type field to distinguish the google openid node.
    /// </summary>
    const string OPENID_TYPE = "http://www.iana.org/assignments/relation/describedby";

    #endregion

    #region xpaths

    /// <summary>
    /// XPath for getting the issuing authority from xrds.
    /// </summary>
    const string AUTHORITY_XPATH =
        "/xrds:XRDS/xri:XRD/xri:Service[xri:Type/text()='{0}']/openid:{1}";

    /// <summary>
    /// XPath for getting normalized id url template from xrds.
    /// </summary>
    const string NORMALIZED_ID_XPATH =
        "/xrds:XRDS/xri:XRD/xri:Service[xri:Type/text()='{0}']/openid:{1}";

    /// <summary>
    /// XPath for CanonicalID node in xrds.
    /// </summary>
    const string CANONICAL_ID_XPATH = "/xrds:XRDS/xri:XRD/xri:CanonicalID";

    /// <summary>
    /// XPath for digital signatures in xrds.
    /// </summary>
    const string DIGITAL_SIGNATURE_XPATH =
        "/xrds:XRDS/ds:Signature/ds:KeyInfo/ds:X509Data/ds:X509Certificate/text()";

    #endregion

    #region error messages

    /// <summary>
    /// Exception message if host-meta based discovery fails.
    /// </summary>
    const string HOST_META_DISCOVERY_FAILED = "Could not fetch xrds url from host-meta.";

    /// <summary>
    /// Exception message if email address is in an invalid format.
    /// </summary>
    const string INVALID_EMAIL_FORMAT = "Email address is not in the correct format.";

    /// <summary>
    /// Exception message if an empty email address is encountered.
    /// </summary>
    const string EMAIL_CANNOT_BE_EMPTY = "Email address cannot be empty.";

    /// <summary>
    /// Exception message if an empty xrds url is encountered.
    /// </summary>
    const string XRDS_URL_CANNOT_BE_EMPTY = "XRDS url cannot be empty.";

    /// <summary>
    /// Exception message if provider url could not be fetched from site xrds.
    /// </summary>
    const string PROVIDER_URL_NOT_FOUND_ERROR = "Could not fetch provider url from site xrds." +
        " See inner exception for details.";
    /// <summary>
    /// Exception message if site xrds signature is not valid.
    /// </summary>
    const string SITE_XRDS_SIGNATURE_INVALID_ERROR = "Site xrds signature is not valid.";

    /// <summary>
    /// Exception message if site xrds could not be retrieved.
    /// </summary>
    const string NO_SITE_XRDS_ERROR = "Could not fetch site xrds. See inner exception for details";

    /// <summary>
    /// Exception message if the web page could not be retrieved.
    /// </summary>
    const string NO_WEB_PAGE_ERROR = "Failed to retrieve web page. See inner exception for details";

    #endregion
  }
}
